Skip to content

release: dev → prod 2026-09-05 (finance train: money invariants, B2C tax invoices, ticker, Razorpay productionization, wallet/overage/payout fixes) - #1491

Merged
teetangh merged 24 commits into
prodfrom
release/dev-to-prod-2026-09-05
Sep 5, 2026
Merged

release: dev → prod 2026-09-05 (finance train: money invariants, B2C tax invoices, ticker, Razorpay productionization, wallet/overage/payout fixes)#1491
teetangh merged 24 commits into
prodfrom
release/dev-to-prod-2026-09-05

Conversation

@teetangh

@teetangh teetangh commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What ships

Finance/Money

Booking

Ops/CI

Docs

Database

The shared Supabase project already carries the union schema for this release. The sidecars (45 constraints / 6 indexes / 3 triggers, verified 2026-09-05) and the fixed leg trigger are already live there, so no push is needed for this release.

Owner actions still open

This release restores production checkout, which has been dead since #1255 and is fixed here by #1435.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

teetangh and others added 23 commits September 3, 2026 18:09
…ation is a row (#1378)

## What

Wave-6 PR C (umbrella #1319; audit trail from #1333). Two gaps in `BookingStatusHistory`, one found in production on 2026-09-03.

- **Every history row now names its appointment.** The seven CAS helpers in `lib/booking/transitions.ts` already read the row's pre-image inside the transaction; that read now also carries the appointment id, so `appointmentId` is stamped on the audit row without any caller changing. Consultation, Webinar, TrialSession and RescheduleRequest resolve it directly. Subscription and Class own `Appointment[]`, so they take the id only when exactly one live appointment exists (`take: 2` is the whole question) and otherwise leave it null with the reason in the helper's doc comment. `transitionSlotCompletion` takes it from each moved row. Helper signatures are unchanged.
- **Creation is a row.** On production, `BookingStatusHistory` had zero rows for a freshly created request because creation is not a transition, so the staff timeline read "nothing has moved on this booking yet" for every new booking. A small `appendCreationHistory` helper writes one opening row with the literal from-status `"CREATED"` (not the `"UNKNOWN"` sentinel, which on this surface means "a concurrent writer moved the row between the pre-read and the update"). It runs in the same transaction as the create at the three creation paths: `handleConsultationCheckout`, `handleSubscriptionCheckout`, and `app/api/slots/request-for-approval/route.ts`, whose nested create is now wrapped in a short transaction so the audit row is atomic with it. Webhook fallback creators are deliberately untouched.
- Docs that asserted the column was never populated (ADR A12, `docs/booking/README.md`, the ER label in `06-dependency-graphs.md`, doctrine skill rule 1, the read model's header) are corrected in place.

## Verification

`prisma generate` + cold `tsc` clean; `__tests__/booking-algorithm` + `__tests__/payments` + `__tests__/maintenance` + `__tests__/booking`: 129 suites / 1,720 tests green; eslint zero; prettier clean. One pin file, `__tests__/booking/status-history.test.ts`, with two cases: a consultation with an appointment gets `appointmentId` on its history row without the caller passing it, and the `"CREATED"` opening row. Two existing mocks updated minimally.

Commit 1 is format-only (the request route was prettier-dirty on `dev`); commit 2 carries the change.

## Restack notes

Adds the `## Changelog: 2026-09-03 — wave 6` heading; #1376 adds the same heading, so the second to land takes a one-line conflict. `checkout.ts` hunks are confined to the two handler bodies plus an import block near the top, clear of #1376's import change.

## Out of scope, found on the way

`expirePaymentPendingRequests` in `scripts/appointments/expire-stale-requests.ts` still flips `APPROVED_PENDING_PAYMENT → EXPIRED` with a bare `updateMany`, so it writes no history row; handed to wave-6 PR B, which is already editing that file.

Part of #1319.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…t the sweep re-attempt unplaced sessions (#1379)

## What

Wave-6 PR A (umbrella #1319; closes the #1206 follow-up). A partial allocation confirmed N of M sessions and nothing ever re-attempted the rest, because `autoAllocate` could only re-plan from scratch, deleting every confirmed row on the way. The allocator now has a top-up mode and the hourly reconciliation sweep uses it.

- **`autoAllocate` takes `topUp`.** For a recurring event with at least one confirmed session, no tentative rows and fewer confirmed sessions than the plan requires, every existing confirmed appointment is treated as fixed: `deleteExistingAppointments` is never called, the fixed sessions stay in the occupancy set and seed the weekly and per-day caps, and only `plan − confirmed` sessions are searched for. A complete event returns `noChange: true` instead of throwing "already fully allocated". Shortfall is `plan − all confirmed`, not `plan − future confirmed`, so a delivered session is never owed twice. Every non-top-up call is byte-for-byte today's behaviour.
- **Nothing is notified on a no-change run.** The suppressor in `allocate()` sends the existing partial or complete notice only when a session was placed, so the sweep cannot page a consultee hourly.
- **The sweep re-attempts.** `reconcile-slot-availability` collects APPROVED subscriptions and SCHEDULED or IN_PROGRESS classes whose window is still open, that have confirmed sessions below the plan and zero tentative rows, and attempts a top-up only when the consultant's availability rows changed after the event's last update. Bounded: 200 candidates read, 25 attempts, a 60-second wall-clock budget, one try/catch per event, counts surfaced on the job result. No schema column: a successful top-up re-stamps the request through its transition helper, so `updatedAt` already means "last attempted".
- **The four allocate routes accept `topUp`**, gated by the same `canOverride` as the other consultant-only flags, and echo `noChange` on success. No UI change: there is no "allocate remaining" control today, and the shortage dialog re-plans deliberately.

## Verification

Cold `tsc` clean (it caught one real bug on the way: `ClassPlan.consultantProfileId` is nullable); `__tests__/booking-algorithm` + `__tests__/payments` + `__tests__/maintenance` + `__tests__/booking` + `__tests__/collaborators`: 130 suites / 1,726 tests green; eslint 3 warnings, all pre-existing on `dev`; prettier clean. One pin file, `__tests__/booking-algorithm/allocation-top-up.test.ts`, whose transaction mock has no delete members: two of four confirmed places exactly two on the weeks the cap leaves open; a complete event returns `noChange` without entering the transaction or notifying; the same fixture without the flag reaches the delete and fails.

Not verified: the sweep under real availability changes against a running app.

## Out of scope, found on the way

- `expirePaymentPendingRequests` bypasses the CAS helper and the money predicate (handed to wave-6 PR B).
- `reconcile-slot-availability` declares 8 of the 10 pool-budget minutes; the top-up pass stays inside it, but the job has no headroom for another tail.
- Availability rows carry a `deletedAt` that nothing reads or writes; deletes are still hard, so "last published" is publish-only.

Part of #1319. Closes the #1206 follow-up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…last bare slot-status writes through the CAS helpers (#1380)

## What

Wave-6 PR B (umbrella #1319). Doctrine rule 2 says a slot is freed by status alone, yet four sites outside the allocator still hard-deleted tentative `SlotOfAppointment` rows, and three status writers still bypassed the CAS helpers.

- **The last tentative-hold deletes become soft cancels.** `expire-stale-requests.ts` (the PENDING-consultation expiry and `releaseStaleRescheduledSlots`), `cleanup-tentative-slots.ts`, the direct-booking arm of `cancel-pending.ts`, and the payment-failed arm in `webhooks/handlers.ts` now call `transitionSlotCompletion` to CANCELLED with `deletedAt`, carrying every original guard (`isTentative`, the no-successful-payment predicate, the re-checked parent status) verbatim into the CAS WHERE. Follow-up reads that counted "remaining slots" now filter live rows, or the EXPIRED transition they gate would never fire again. Occupancy already ignored tombstones and is untouched. Repo-wide slot deletes: 11 → 6; production code: 9 → 4, all four the allocator's sanctioned never-paid release.
- **The from-set is the default CANCELLED set, deliberately.** `auto-complete-appointments` flipped any SCHEDULED slot to UNVERIFIED an hour past `endsAt` with no `isTentative` filter, so a hold on a past-dated slot was routinely UNVERIFIED before the sweeps saw it; a from-set narrowed to SCHEDULED/RESCHEDULED would strand exactly the holds these sweeps exist to free. Note for future callers: the helper overwrites `completionStatus` in the caller's WHERE with its from-set, so a status scope must be passed as `fromIn`.
- **Three more bare status writes go through the helpers.** `expirePaymentPendingRequests` moves APPROVED_PENDING_PAYMENT → EXPIRED through `transitionConsultationRequest` / `transitionSubscriptionRequest` with the money predicate repeated in the WHERE, one transaction per request, a raced capture counted as skipped; it also gains a 500-per-run cap with a warning. `completeIndividualSlots` in `auto-complete-appointments` (the last slot-completion writer outside the helper, and the source of the UNVERIFIED interaction above) now uses `transitionSlotCompletion` with `isTentative: false`, `deletedAt: null` and `fromIn: [SCHEDULED]`, so an unpaid hold is never marked UNVERIFIED and a tombstone is never touched. The maintenance preflight's upcoming-slot count filters `deletedAt: null`.
- **The forbidden-delete pin is global.** `slotOfAppointment.delete(Many)` joins the forbidden list and a new case walks `scripts/ jobs/ lib/ app/ utils/` against an allowlist of exactly the allocator, `prisma/` and `scripts/db/`.
- The cancel-vs-webhook chaos scenario asserted `tentativeLeft === 0` after a winning cancel, true only while the cancel deleted; it now counts live holds and its arm/restore steps clear and replay `deletedAt`. Source-level fix; the race workflow fires on push to `dev`.

## Verification

Cold `tsc` clean; `__tests__/booking-algorithm` + `__tests__/payments` + `__tests__/maintenance` + `__tests__/booking`: 128 suites / 1,720 tests green; eslint zero on every touched line (12 pre-existing warnings in untouched files); prettier clean. Commit 1 is format-only for the five files that were prettier-dirty on `dev`.

Not verified: the sweeps against a running app; the chaos scenario was not executed.

## Restack notes

Appends to the `## Changelog: 2026-09-03 — wave 6` section; expect a one-line heading conflict with whichever wave-6 PR lands first.

Part of #1319.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
… defaults off (#1382)

## What

Wave-6 PR D (umbrella #1319; the rate-card decision recorded on #1338). `resolveEffectiveRateCard` resolves membership override → contract-scoped → plan-scoped → org → default, but its only settlement caller, `resolveOrgSplit`, passed just `{ orgId, membershipOverrideId, at }`, so contract- and plan-scoped cards could be created and were never selected. Owner decision 2026-09-03: forward the scope behind a flag that defaults off, with a settlement parity test.

- **Flag.** `RATE_CARD_SCOPED_RESOLUTION`, on only when the value is exactly `on`, read per call through `isScopedRateCardResolutionEnabled()` in `lib/api/organizations/rate-card.ts`. Off, the resolver call is the pre-change call verbatim and the scoped tiers are never queried.
- **Forwarding.** `resolveOrgSplit` takes the booking (`paymentId`, `appointmentType`) and, with the flag on, forwards `planType` (an exhaustive `Record<AppointmentType, CoveredPlanType>`, so an enum rename fails to compile), `planId` where the booking carries one (webinar and class), and `contractId` resolved at settlement time through `BookingUtilization → ProgramAssignment → Program → Contract`, which exists for org-funded bookings because checkout writes the utilization row in the same transaction. `at` stays `payment.createdAt`. The collaborator leg stays unscoped (ADR 18 org-blindness).
- **Tenancy guard.** The contract is forwarded only when `Contract.organizationId` equals the host org being resolved. The contract belongs to the sponsoring org while `resolveOrgSplit` resolves the expert's host org, and the resolver matches `ownerContractId` without re-asserting the org, so an unguarded forward could settle one tenant's booking on another tenant's negotiated split. The guard can only select fewer cards, never a wrong-tenant one; contract scope is therefore reachable only where sponsor and host coincide.
- **Both surfaces see the flag.** The earnings healer workflow (`sync-payment-earnings.yml`) carries the variable from repository `vars`, because it accrues through the same `createEarningsFromPayment` as the capture webhook; set on Netlify alone, one booking would settle two different ways depending on which surface caught it. Declared in the required-secrets reference and the feature-flags doc; the #1335 narrowing in the booking-to-earnings doc is rewritten to describe the flag.

## Verification

Cold `tsc` clean; `__tests__/booking-algorithm` + `payments` + `maintenance` + `booking` + `enterprise` + `collaborators`: 221 suites / 2,434 tests green; eslint zero on every touched line; prettier clean; hygiene guard ok. One pin file, `__tests__/payments/rate-card-scoped-settlement.test.ts`, which drives the real chain from `createEarningsFromPayment` to the bps on the earnings rows: flag on selects the plan-scoped card, flag off selects the org default and issues no `planId` query, and a sponsoring contract owned by another org is never queried. Three existing suites that partially mock the rate-card module gained one line each.

Commit 1 is format-only for six files that were prettier-dirty on `dev`. No live settlement was run.

## Deliberately left out

A card scoped to a specific consultation or subscription plan is still never selected: `planType` reaches those tiers for all four kinds, but only webinar and class carry a plan id into settlement. Closing it means widening the payment projection at the three `createEarningsFromPayment` call sites; recorded in the docs table.

Part of #1319.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…n, not the pool it is blocking (#1435)

* fix(payments): the checkout consent gate reads through the transaction, not the pool it is blocking

Netlify runs this app with a pg pool of PG_POOL_MAX=1 and a 3 s connect
timeout. An interactive Prisma transaction checks out that single connection
and holds it until it commits, so any query sent to the global client while a
transaction is open queues for a connection that only the blocked transaction
can release. The request cannot make progress and pg eventually gives up with
"timeout exceeded when trying to connect".

That is what killed POST /api/checkout on the deploy preview: the DPDP
SESSION_BOOKING gate in validateSlotAvailability called checkConsent, which
read on the global client, and validateSlotAvailability is called from inside
three separate transactions on the plain Razorpay consultation path
(calculateAmountAndValidate, revalidateInsideLock and the Serializable booking
transaction). The first of them deadlocked against itself and the route
answered 500 with the pg message, before any row was written. The org-sponsored
branch of revalidateInsideLock had the same defect on its own consent check.
Neither is new to the finance train; both have been on dev since the LCY-2
consent cascade landed, and production runs the same single-connection pool, so
consultation checkout could not take a payment there either.

checkConsent now takes an optional client that defaults to the global one,
following the getUserCredits convention, and both in-transaction call sites
pass tx. The gate itself is unchanged and still fails closed. The redundant
dynamic imports in validateSlotAvailability are gone; both symbols were already
static imports at the top of the file.

The pin models the pool rather than the query: a global-client call raised
while a transaction is open throws the same pg error the preview logged, so the
test fails against the unfixed code with "timeout exceeded when trying to
connect" and passes once the gate reads through tx. A second case asserts the
withdrawn-consent block still rejects, since a fix that silently disabled the
gate would also make the first case pass.

lib/compliance/dpdp.ts was already failing prettier --check on dev; formatting
the file to commit this change also settles that one unrelated line.

Part of #1421

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): org-programme notifications look up their assignment after the checkout transaction commits

Three Novu bells on the org-programme path issued their ProgramAssignment
lookup on the global Prisma client from inside the Serializable checkout
transaction. They were fire-and-forget, so they did not block the booking, but
under PG_POOL_MAX=1 that query queues behind the transaction's own connection
and dies at the 3 s pg connect timeout — and the .catch swallowed it, so on
Netlify the bell was simply lost. The comments claimed the outer client was
chosen to read committed state; the pool makes that impossible from where the
call sat.

All three now capture what they need inside the transaction and ring after it
has settled. The 80% cap-near warning and the member-due overage charge travel
out on the transaction's return value, so they ring only for the attempt that
actually committed, and a P2034 retry can no longer ring them twice — which is
what the capNearNotified flag existed to prevent, so it is gone. The
cap-exhausted bell is the one whose news IS the refusal, so it rides out on a
holder and rings from the retry wrapper's catch, preserving today's behaviour
of telling the org even though the booking rolled back.

recordOverageAtCheckout returns the pending member-due notification instead of
ringing it, and the ringing moves to notifyOverageDueAfterCommit in the same
module, so the Novu graph stays where its docblock says it lives.

The two bells share one lookup and one roster, so the duplicated query and
error handling collapse into dispatchProgramBell.

approval-path-correctness asserts on checkout source text within a fixed
character window of the STEP 5 marker, which a comment above the call can push
the call out of. It now strips comments and looks in a wider window, so it
pins the code rather than the prose.

Part of #1435

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…nment, bugs/ annotations (#1392)

* docs(finance): the 2026-09-03 verdict record, ADR 26 (GST principal model), compliance-doc alignment, and the bugs/ notes annotated

Closes #1373
Part of #1319

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* docs(payments): the money subsystem in four high-level diagrams

A newcomer's map of where money truth is written, what may lag behind it,
and which sweep closes each gap, for both the B2C and the B2B paths.

Part of #1373

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* docs(finance): review-comment triage round 1 on the verdict record and ADR 26

Aligns "Known gaps" sections in bugs/*.md with the 2026-09-03 verdict
tables that already sit above them in the same files (several rows still
called FIXED-BY/STALE/OVERSTATED items open). Resolves two Locked-vs-
Recommendation contradictions by labeling the superseded recommendation
historical. Corrects the RBI architecture-memo status self-contradiction,
the stale SCIM-501 checklist/audit-proposal text against the now-shipped
lib/scim/, the inbox/outbox terminology and the five-minute hard-bound
claim in the payments HLD, and clarifies the allocationIdempotencyKey
schema comment to describe the known concurrent-replay 409 gap. Adds
ADR 26's missing Alternatives-considered section and corrects its B2C
tax-invoice claim to in-flight (no ConsumerInvoice/ConsumerCreditNote
model exists yet).

Leaves the TCS/GSTR-8 conditionality, SAC-code mapping, RazorpayX
FFMC/APSO licensing claim, and TDS-form-renumbering comments as
needs-decision — those touch tax/regulatory statements the verdicts
were deliberately locked against, not doc-consistency bugs.

Part of #1373

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* docs(compliance): TCS is CA-gated, the RazorpayX licensing claim is sourced, and the TDS forms carry their 2026 numbers

Part of #1373

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…weeps (#1390)

* chore(cron): a Netlify scheduled ticker drives the sub-hourly money sweeps, and the fleet stops promising cadences Actions cannot keep

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* chore(env): document CRON_SECRET and CRON_TICK_BASE_URL for the ticker

The E2E pass found CRON_SECRET absent from every local env, so the
cleanup routes were unreachable; the sample now names both ticker vars.

Part of #866

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* chore(cron): the ticker strips trailing slashes without a backtracking regex

Part of #866

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(cron): report ticker failures honestly, stop masking earnings-route errors

Round-1 triage of PR #1390 review comments. The scheduled ticker returned 200
even when a target failed, and sync-payment-earnings/release-earnings pinned
status:()=>200 over their own success:false results, so both self-reported
healthy on error. Also corrected cron-setup.md's local-invocation
instructions, which claimed netlify dev fires a scheduled function on its
real cadence and that a scheduled function can be curl'd directly by URL —
neither is true per Netlify's docs; netlify functions:invoke is the only
supported local trigger.

Left unchanged as needs-decision (money-semantics, not this triage's call):
sharing the ticker's ?limit budget across paired workers in
abandoned-payments and reconcile-pending-refunds, rejecting invalid `limit`
as 400 instead of unbounded, and adding deterministic ordering to bounded
cleanup-abandoned-payments queries.

Part of #866, #1010

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* chore(cron): each sweep pass owns its limit, bad limits are refused, bounded queries drain oldest-first

Part of #866

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(cron): expiry and slot-reconcile sweeps transition through the CAS helpers and refund exactly the rows they expired

Both subscription expiry arms in scripts/appointments/expire-stale-requests.ts
wrote status with a bare updateMany. The PENDING arm re-ran the 30-day
predicate in the write instead of naming the ids it had read, so a
subscription that crossed the cutoff between the two statements was expired by
the write, excluded from refundPaymentsForExpired (which was handed the READ
set), and never revisited because the next run reads PENDING only: paid,
expired, silently unrefunded. The APPROVED-unallocated arm had the mirror
defect, refunding every read id even when the write matched fewer. Neither
wrote a bookingStatusHistory row. Each row now expires through
transitionSubscriptionRequest in its own transaction with the cohort's own
predicate repeated in the CAS WHERE, a from-set that is a deliberate subset of
REQUEST_ALLOWED_FROM.EXPIRED, and a recorded actor and reason; only the ids the
helper actually transitioned are refunded. Both arms take the file's existing
per-run cap and oldest-first drain now that they are per-row.

The tentative-clear sweep in scripts/appointments/reconcile-slot-availability.ts
scoped its write by id alone. A partial reschedule releases a slot as
isTentative=true / completionStatus=RESCHEDULED while leaving the parent
APPROVED, which the sweep's parent-status guard does not see, so such a slot
landing between the read and the write was stamped confirmed and blocked the
calendar for a session nobody would deliver. The write now repeats the cohort
predicate (ADR 21) and logs the shortfall when rows leave the cohort mid-run.

Closes #1423
Closes #1424

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ng what Payment.amount has always meant (#1385)

* fix(payments): the leg-sum identity excludes referral credits, matching what Payment.amount has always meant

Two definitions of `Payment.amount` were both being enforced and could not
both be true. The schema has always described it as the final amount charged
to the gateway — after discounts and tax, and after referral credits are
deducted — and `handleCheckout` writes a CARD leg equal to exactly that
figure. `lib/referrals/service.ts` then writes a positive REFERRAL_CREDIT leg
for the credit it just applied, so the legs on a credit-funded booking added
up to `amount` plus the credit.

Every reader of the invariant, though, took it as a plain sum over all
non-reversal legs: `checkPaymentLegsSumToAmount`, the checkout sweep, the
nightly reconciler, and the `payment_legs_sum_to_amount` constraint trigger.
That trigger is DEFERRABLE INITIALLY DEFERRED and is live on the database, so
it fired at COMMIT and rolled back the entire checkout transaction for any
booking that spent referral credit.

Either the field meant the pre-credit price — in which case the gateway was
being asked for the wrong number — or the credit leg did not belong in the
sum. This keeps the field's long-standing meaning and narrows the identity
instead: the funding sum is now Sigma(non-reversal, non-REFERRAL_CREDIT legs)
=== Payment.amount, in the checker, in the trigger, and in the docs. The
credit leg is untouched and still posts as the PLATFORM_PROMO debit; the
DISCOUNT plug in earnings-service.ts already based itself on the sum of
funding-leg debits including PLATFORM_PROMO, so the journal side needed no
change at all.

Also closes the inverse of #1357 7.4 in `rollupOrgInvoiceAccruals`: the
allowed-from guard IS the filter, so an OverageEvent that is no longer PENDING
silently did not move to ACCRUED and the discarded count was the only
evidence. Its marginal is already inside the invoice's line amounts, so the
next rollup bills it again. The count is now captured and a zero records a
system error naming the event and the invoice. The invoice still commits —
refusing to issue would strand the whole cycle.

NOTE FOR DEPLOY: `prisma db push` does not manage triggers. The live database
still carries the old predicate until `npm run db:leg-triggers` is run, and
credit-funded checkouts keep failing at COMMIT until then. The script is
idempotent.

Closes #1347
Closes #1357

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): the leg-sum trigger skips the LICENSE zero-leg case like the checker does

`checkPaymentLegsSumToAmount` carves out the payment whose only non-reversal
legs are zero-value LICENSE legs: a licensed seat is absorbed at contract time,
so the leg is deliberately 0 while `Payment.amount` stays at the full list
price, and the sum comparison is structurally false for every one of them.

The constraint trigger never learned that carve, even though its header claims
to mirror the checker exactly. It summed to 0, compared against a full-price
`amount` and raised `check_violation` at COMMIT — rejecting precisely the
checkout the application-side checker waves through. Same shape as #1347: a
live DB constraint that is stricter than the invariant it claims to enforce, so
a legitimate booking cannot commit.

`assert_payment_legs_ok` now counts the non-reversal legs and how many of them
are something other than a zero-value LICENSE leg, and skips the sum comparison
when the second count is zero and the first is not. Both counts deliberately
span REFERRAL_CREDIT so a credit sitting beside a licence leg keeps the payment
in the comparison, exactly as the checker does. The reversal-pair loop still
runs in the carve case. No object renamed, `-- SPLIT` cadence unchanged.

Verified against a throwaway local Postgres 16 cluster: the file applies
cleanly and 15 leg shapes behave as intended, including the licence-only carve,
a licence leg beside real drift, and a credit beside a licence leg. The same 14
shapes run through `checkPaymentLegsSumToAmount` agree with the trigger on
every case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): quote the camelCase leg columns so the constraint trigger runs at all

`assert_payment_legs_on_leg_write` referenced `NEW.paymentId` / `OLD.paymentId`
unquoted. PL/pgSQL case-folds a bare identifier, so it looked for `paymentid`
on a Prisma-generated camelCase table and raised

    record "new" has no field "paymentid"

on EVERY PaymentLeg insert, update and delete. Not just the drifted ones — the
funding sum was never reached, so the trigger has guarded nothing since it was
written in #1232 and the re-parenting branch added in #1233 inherited the same
mistake. Verified by applying both the current file and the base `dev` revision
to a throwaway local Postgres: a single CARD leg exactly matching
`Payment.amount` still failed to commit.

This matters now because #1347 ships with an instruction to re-run
`npm run db:leg-triggers`. Installing the function as written would have
converted a silent no-op into a hard failure on every checkout that writes a
leg, so the quoting has to be right before that command is run.

Quoting the four references restores the intended behaviour. Postgres
short-circuits the `AND`, so the `OLD` reference in the re-parenting branch is
never evaluated on an INSERT. Verified locally across all four paths: insert,
same-payment update, a `Payment.amount` update, a leg delete that leaves the
payment under-funded, and a cascade delete of the parent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): review round 1 — reversal-pair regression, system event after commit, honest orphan message, checker mirrors the trigger

Part of #1347

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): the leg checker rejects a zero reversal like the trigger, and the invoice rollup retries a serialization abort before reporting it

Part of #1347

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… credit note on refund, and a monthly outward-supplies register for the CA (#1393)

* feat(payments): every consumer supply gets a statutory tax invoice, a credit note on refund, and a monthly outward-supplies register for the CA

The platform bills as principal supplier for GST (ADR 26): checkout charges
18% and settlement credits GST_PAYABLE. Organizations received a Rule 46
document for that; personal buyers received nothing at all, and there was no
register the platform's own outward supplies could be filed from.

This adds the document trail and only the document trail. It posts nothing to
the ledger, derives no tax from a rate (the heads are split out of the tax the
buyer was actually charged, so the invoice agrees with GST_PAYABLE to the
paise), and builds no IRN.

Closes #1365
Closes #1370
Closes #1361
Closes #1358

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* chore(payments): the B2C invoice PR clears the Sonar gate — shared PDF serving, hardened workflow, smells

The two consumer PDF routes were near-identical: everything except which row
they load and which renderer they call is one contract, and it is the part
that must not drift, so it moves into lib/pdf/serve-consumer-pdf.ts. The IST
previous-calendar-month arithmetic the register export copied from the GSTR-8
draft moves into lib/compliance/ist-period.ts, used by both.

The register workflow now installs without lifecycle scripts and runs the
pinned local binaries rather than resolving packages at execution time,
matching tds-return-draft.yml.

The rest are smells on new lines: optional chaining, nested ternaries replaced
by named resolvers, an explicit boolean comparison, Readonly props, the two
table cells hoisted out of their parent components, and an explicit JSX
separator where the newline was being stripped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* chore(payments): one billing-state hook, one document frame, one best-effort minter — the B2C invoice PR under the duplication gate

Four copies of the same twenty lines is what the density gate was measuring,
and each copy was also a place the next change could be forgotten.

- `useBillingState` owns the value, the profile pre-fill and its latch, and
  the POST body field, so each checkout page adds a hook call, a two-prop
  mount and three spreads instead of a block.
- `StatutoryDocumentFrame` is the page furniture both consumer documents
  share: supplier block, recipient block, number and date, the supply table,
  the tax totals and the footer. The Devanagari registration moves there too,
  so the process registers the face once. The org documents are untouched and
  `orgStyles` is byte-identical — they carry an IRN block, a status badge and
  a due date that have no consumer counterpart.
- `lib/data/payments-select.ts` holds the select shapes the admin and buyer
  payment routes share. These selects are the privacy boundary — `Dispute`
  carries internalNotes and evidence — so a copy is how a new column leaks.
- `mintConsumerInvoiceBestEffort` is the identical try/catch both confirmation
  paths wrapped the mint in. Still never rethrows, still a Sentry warning; the
  gateway-intent lookup moved inside the guard, where it belonged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* chore(env): document PLATFORM_GSTIN and PLATFORM_INVOICE_PREFIX in .env.sample

The E2E pass found both absent locally, so the consumer-invoice mint no-ops
silently; the sample now names them next to SUPPLIER_STATE_CODE.

Part of #1365

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* chore(pdf): the statutory frame drops a nested ternary and a void expression

Part of #1365

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(compliance): review round 1 — real calendar dates, an honest period label, the credit-note rate, and an HTTP twin for the register

CodeRabbit round 1 on the B2C statutory-invoice PR. Eight of the thirteen
comments were actionable and are fixed here; four touch a tax figure, the
place-of-supply default or which rows the register exports and are reported as
needs-decision rather than changed unilaterally.

- parseIsoDateOverride round-trips the parsed instant, because the ISO parser
  rolls "2026-02-30" over to 2026-03-02 and the NaN check accepted it, so an
  operator override silently exported a period nobody asked for.
- The register's period label named periodEnd, which the query excludes (lt), so
  a CA read the file as covering one extra day. It now names the last day
  actually covered — stepping back a whole day, not a millisecond, because the
  label renders in IST and periodEnd - 1ms still lands in the next IST day.
- The consumer credit-note PDF carries the original invoice's taxRateBps, which
  Rule 46 read with s.34 requires on the reversed head.
- consumerStateCode at checkout is restricted to the real GST state codes. It
  only had to be two characters, and numericStateCode passes any two digits
  through, so "99" reached a statutory document as a real-looking state and the
  register filed it under a state that does not exist without a warning.
- ConsumerInvoice and ConsumerCreditNote get @@index([issuedAt]); both composite
  indexes lead with a column the monthly period scan does not constrain.
- The register workflow declares contents: read and drops the checkout
  credential. Action SHA-pinning was declined: no workflow in this repo pins,
  and pinning one is drift, not hardening.
- PLATFORM_INVOICE_PREFIX moves out of "missing — money-critical", since the
  series falls back to FAM and issuance is unaffected.

Also folds in the missing HTTP twin: the register export core is now exported
and shared by the Actions entry point and app/api/cleanup/gst-outward-register-export,
so both take the same fail-closed lock and an overlapping manual run answers 409
instead of racing the gapless series. The twin does not write the CSV — a
serverless filesystem is read-only and nothing would collect it.

Part of #1365

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(compliance): IST period boundaries, GSTIN-derived supplier state, a cumulative credit-note cap, and heads that sum to the note total

Four review decisions on the B2C tax-invoice work, all of which changed a
figure rather than a comment. Nothing has been exported or filed yet, so the
semantics are corrected in place with no migration.

An IST calendar month now runs from IST midnight to IST midnight. Both
boundaries were built from UTC components, which dropped the first five and a
half hours of the month and swallowed the same span of the next one; the
default window for the 3rd-of-September run moves from 2026-08-01T00:00Z to
2026-07-31T18:30Z, and its exclusive end from 2026-09-01T00:00Z to
2026-08-31T18:30Z. Both compliance jobs read the shared helpers, so both move
together, and the register label still prints the IST days the file covers.

The platform's own state is now derived from PLATFORM_GSTIN first, because the
first two digits of a GSTIN are the state of registration by law, with
SUPPLIER_STATE_CODE as the fallback for a GSTIN that carries none. Reading the
env var alone put IGST on an intra-state supply whenever it was unset. When the
two disagree the mint fails closed, exactly as a missing GSTIN already did,
because either choice burns a gapless Rule 46 number on a document that cannot
be corrected in place.

Credit notes are capped against the value the invoice still has left to credit
rather than against its full total. A partial refund and a later lost
chargeback are two distinct idempotency keys against one invoice, so neither
probe short-circuits the other and the per-note cap allowed them to credit past
100% of the supply, understating the period's output tax.

The note's heads are prorated from the invoice's total tax and then re-split by
the invoice's own floor-CGST rule, instead of each head being floored
independently. The old shape left the stored row short of its own total by a
paise or two, which the register then reported as a reconciliation warning; the
identity taxable + cgst + sgst + igst == total now holds by construction and no
head can exceed the invoice's.

Part of #1365

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* docs(payments): describe the resolved supplier state and the cumulative credit-note cap

The B2C invoicing guide still said the supplier's state came from the
environment variable and that a credit note was capped at the invoice total,
both of which the preceding commit changed. The required-secrets table said an
unset SUPPLIER_STATE_CODE stopped the intra-state split from being decided,
which is no longer true now that the GSTIN is read first; what stops invoicing
today is a value that contradicts the GSTIN.

Part of #1365

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…rds Razorpay already has (#1386)

* fix(payments): fence Stripe behind STRIPE_ENABLED and give it the guards Razorpay already has

Stripe was fully live: every checkout page hardcoded an active Stripe entry,
routeGateway honoured an explicit STRIPE request unconditionally, and
assertGatewayUsable only rejected the DODO_PAYMENTS stub — all on sk_test_
keys. Razorpay is the primary rail for domestic and international collections
and Dodo Payments is the sanctioned post-MVP international gateway, so Stripe
is a contingency only and must be unreachable by accident.

assertGatewayUsable now throws DisabledGatewayError for STRIPE unless
STRIPE_ENABLED=true, and both doors into a live charge go through it: the
checkout router and createPaymentIntent, which is what the approval-payment
path uses. Auto-routing is unchanged and refunds stay outside the fence so a
Payment already taken on Stripe is never stranded. The checkout UI reads
NEXT_PUBLIC_STRIPE_ENABLED from one shared list instead of four hardcoded
copies.

core/stripe.ts also gains the guards razorpay.ts has carried since #825: the
sk_test_-in-production check (in the lazy initializer, per #1376, with the
next-build carve-out and a STRIPE_ALLOW_TEST_KEYS_IN_PRODUCTION opt-out), a
required idempotency key on refunds passed as a stripe-node request option
rather than a body param, a positive-amount check in place of `amount ||
undefined`, a captured-intent precondition, an explicit timeout and retry
budget, a null session.url error instead of a non-null assertion, and a warn
on an unmapped refund status. The Stripe charge.refunded webhook now passes
providerPaymentId like the Razorpay dispatcher does. Dead
lib/payments/core/transactions.ts is removed.

Closes #1351
Closes #1359

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): resolve the Stripe charge id explicitly on the refund webhook

`latestRefund.charge` is `string | Stripe.Charge | null`, and the Stripe
dispatcher parses its body with JSON.parse, so tsc cannot catch the expanded
case. When Stripe expands the field, `charge || id` handed an object to
handleRefundCreated as the provider payment id, which would never match a
providerPaymentId column and would land in a log line as [object Object].

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): a fenced gateway is a business rejection, not a 500, and Stripe.js no longer loads behind the fence

Part of #1351

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): review round 1 on the Stripe fence — a restricted test key is still a test key, and a refund amount must be whole paise

Part of #1351

Three review findings, all verified against the code as it stands after
5e75d36:

- The production test-key guard matched only `sk_test_`. Stripe's RESTRICTED
  test keys carry `rk_test_` and are the prefix Stripe itself recommends for
  server-side use, so the more security-conscious mistake walked straight
  through the fence and built a live client in test mode. Both prefixes now
  count. Also settles SonarCloud S6557 by switching the regex to startsWith.

- `refundPayment` rejected `<= 0` but nothing else, and NaN, Infinity, a
  fraction and an unsafe integer all compare false against zero. Those reached
  `paymentIntents.retrieve` and came back as a generic gateway error the caller
  books as a rail failure rather than the upstream arithmetic bug it is. The
  guard now requires a positive safe integer, matching the ledger's posting
  guard.

- The fence documentation claimed the guard stops every Stripe payment intent.
  It does not: `createPaymentIntent` returns a mock intent before it consults
  `assertGatewayUsable`, deliberately, so the Mock Pay flow keeps working with
  the fence closed. Both doc surfaces now say so, and both now describe the
  widened test-key guard.

Also extracts the typed-code lookup this PR added to `classifyError` into its
own function, which puts that function back under the cognitive-complexity bar
(SonarCloud S3776, 16 of an allowed 15). Behaviour is unchanged.

Verified: the real `getStripeClient` and `createStripeRefund` under a mocked
Stripe SDK, no database and no server. Before, `rk_test_` under NODE_ENV=production
returned a built client and all four bad amounts reached the SDK; after, the key
throws STRIPE_TEST_KEY_IN_PRODUCTION and every bad amount throws INVALID_AMOUNT
with zero SDK calls, while a legitimate 45000-paise refund is untouched.
53 payments suites / 510 tests pass, eslint clean, prettier clean, cold tsc
clean apart from the two known schema-drift files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(checkout): frozen-wallet and consent refusals reach the buyer as business errors with actionable toasts

Closes #1426

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… gets a CSV instead of a console dump (#1389)

* feat(compliance): org-rail TDS reaches the Form 140 draft, and the CA gets a CSV instead of a console dump

Host-organisation payouts computed withholding, deducted it from the
disbursement and credited TDS_PAYABLE, but never wrote a TDSRecord — so an
entire rail of real statutory deductions was invisible to the filing side, and
the quarterly draft carried a standing warning saying exactly that.

TDSRecord and TdsAdjustment now carry either rail. markOrgPayoutCompleted
writes the deduction row at COMPLETED and nowhere else, markOrgPayoutReversed
nets it back out, and the draft builder groups by (deductee type, deductee id).
The draft that reaches stdout stays masked; the full PAN goes only into a CSV
in the private bucket, reachable through one authenticated admin hop.

Closes #1354
Closes #1362

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(csv): a bare negative number is not a formula, so the TDS reversal rows keep their sign

escapeCsvField prefixed an apostrophe to anything starting with `-`, which is
right for a crafted `-1+1` and wrong for a plain `-500`. The TDS return CSV
carries negative paise on every reversal row, so the CA's numeric column
imported as text.

Plain numbers now bypass the guard. Every other leading `-`/`=`/`+`/`@` value
is still neutralised.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(compliance): the org TDSRecord is dated at the completion instant, not the batch

markOrgPayoutCompleted took the record's financialYear from the batch-time pin
on OrganizationPayout and let recordOrgTDSDeduction default the quarter to
getIndianFYQuarter(). A batch built in late March that settles in April
therefore filed FY 2025-26 quarter 1 — a period that does not exist.

TDS on a payout is dated at payment, so both halves of the period now come from
the completion instant, including the FY window the cumulative credited figure
is summed over. recordOrgTDSDeduction takes an optional quarter so a caller
that knows the instant can supply both together. OrganizationPayout's
tdsRateAppliedBps stays pinned at batch time because it is what was actually
withheld; tdsFinancialYear stays as the batch-time audit stamp.

Also completes a comment in markOrgPayoutFailedInternal that lost its last
words in an earlier edit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(compliance): the TDS return job reaches storage through the Node-safe core, so its scheduled entrypoint can load

The generic storage helpers were added to lib/pdf/storage.ts, which sources its
client from lib/supabase.ts. That module opens with `import "server-only"`, a
marker whose main entry does nothing but throw outside Next's react-server
resolution, so the new scheduled workflow's entrypoint died during module
evaluation before a line of its own code ran (#1270) — the same invisible
failure shape that had left five crons never once completing.

uploadPrivateFinanceObject, privateFinanceObjectExists and
createPrivateFinanceSignedUrl now live in lib/storage/private-finance-object.ts
and take their admin client from the marker-free lib/supabase-storage-core.ts.
lib/pdf/storage.ts re-exports all three, so the invoice PDF route is unchanged;
the export job and the admin route import the leaf module directly.

The workflow already declared NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY, so rule 3 of the
guard was already satisfied and no secret is new.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(storage): the private finance bucket provisions itself on first use — it never existed on the live project

`org-invoices` had never been created on the live Supabase project. The first
end-to-end run of the quarterly TDS return export died inside
`uploadPrivateFinanceObject` with `Bucket not found`, and the organization
invoice PDF route carried the same latent fault since April, because both write
through this helper. The original comment said the bucket "must exist; create
via the Supabase dashboard" and nobody ever did.

`ensurePrivateFinanceBucket` now runs before every upload. It asks the admin
client for the bucket and creates it private, with a 25MB per-object limit, only
when `getBucket` reports it absent, memoizing success per process. A rejection
is not memoized, so a transient Supabase failure does not pin the process. A
lost create race re-probes rather than matching Supabase's error wording.

Not `ensureBucketExists` from the same core module: that one probes with the
anon client via an object list, which cannot answer reliably for a bucket that
is private by design, and it reports failure as a `false` the caller would only
discover later at the upload.

The `invoices` bucket that does exist is not this one renamed. It predates the
`org-invoices` name by a month, holds a single object under a different path
scheme whose id matches no row in `users`, `ConsumerInvoice` or
`OrganizationInvoice`, and no code in the repo has ever referenced it.

Part of #1354

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(compliance): the return draft targets the quarter that CLOSED, and the full-PAN CSV is ADMIN-only

CodeRabbit round 1 on #1389.

- The scheduled workflow fires on the 5th of the month after a quarter ends,
  but the job derived its period from "today", so the 5 April run exported five
  days of the new FY instead of the FY 2025-26 Q4 that was actually due.
  `closedIndianFyQuarterOf` derives BOTH halves of the period from one instant,
  and the workflow inputs plus the filing doc now say what it does.
- The §393 payment-code window used an inclusive UTC-midnight bound, so a rate
  row effective at the next quarter's start won the lookup. Seeded rate rows are
  dated `T00:00:00+05:30`, so the 1 Apr 2026 IT2025 row was being applied to
  FY 2025-26 Q4 — exactly the misclassification the comment above it forbids.
  The bound is now exclusive and IST-aligned.
- The full-PAN CSV route admitted STAFF through `requirePrivilegedAuth`; it is
  the same decrypted-PAN class of data `/api/admin/tds?view=form26q` has always
  gated on ADMIN. Its query is now a Zod schema that rejects "2foo" and a
  non-consecutive FY label instead of silently naming another quarter's object.
- The `view=form26q` rows carry the org rail's identity (type, organization id,
  deductee name, org PAN) rather than a null consultant id and a null PAN.
- S3776/S3358 on the export job: the period resolution, deductee identity fetch,
  baselines, payment-code lookup, row assembly and PAN decryption are named
  helpers, and the deductee ternary is one of them. No behaviour, lock key or
  job name changed.

Left for a decision: `markOrgPayoutCompleted` still skips `recordOrgTDSDeduction`
when a payout carries TDS with a null `tdsRateAppliedBps`
(lib/payments/payouts/org-payout-service.ts:1158) — admitting those rows means
making `TDSRecord.tdsRateBps` nullable, which changes what reaches the return.

Part of #1354

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payouts): an org payout that withheld TDS without a stored rate is reported loudly instead of skipped silently

`markOrgPayoutCompleted` gated the statutory `TDSRecord` write on
`payout.tdsRateAppliedBps` being truthy. A payout batched before that
column existed carries a positive `tdsAmountPaise` and a null rate, so
the completion still credited TDS_PAYABLE while the filing row was
dropped without a trace — withholding that never reached the quarterly
return.

The rate stays non-null on `TDSRecord` and nothing is backfilled: those
pre-column rows are the whole nullable population, pre-MVP data is reset
before launch, and every payout batched from here on carries the rate.
Instead the gap now pages. After the transaction commits — the payout
must still succeed, since the cash has moved — the service calls
`recordSystemError` under the PAYOUT category with the payout id, the
organization id and the withheld amount, and mirrors it to Sentry at
warning level through `reportSentryMessage`. The rate is deliberately
not reconstructed from `tdsAmountPaise / gross`, because
`computeTdsForPayout` floors its product and that division cannot invert
it exactly.

The gate itself becomes an explicit null-and-positive check, which is
the same set of values the old truthiness test admitted.

Part of #1354

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* style(docs): prettier-format the three TDS docs this PR already touches

Part of #1354

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… payment id, deferred webhooks page, and the post-payment channel leg is re-driven off the appointment row (#1391)

* fix(payments): refunds and disputes find their Payment by the gateway payment id, deferred webhooks page, and the post-payment channel leg is re-driven off the appointment row

Closes #1352
Closes #1353
Closes #1356
Part of #1358

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(webhooks): the either-id lookup keeps the old reachability — soft-deleted payments still take their refund and dispute events

The `findFirst` that replaced `findUnique({ where: { paymentIntent } })` in
handleRefundCreated and handleDisputeCreated carried a `deletedAt: null` filter
the original lookup never had. That is a semantic narrowing on a money path: a
Payment soft-deleted after capture would stop matching, so its refund event
would DEFER and be given up on after 168h, and its dispute would page as
CRITICAL_DISPUTE_UNLINKED with earnings left payable. Nothing about adding a
second key justifies excluding those rows. The OR-match now reaches exactly what
the old lookup reached, plus the gateway payment id.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* test(security): the DM-precedence source pin follows the channel block to ensure-channels.ts

Two suites assert against `lib/payments/webhooks/handlers.ts` as source TEXT, so
extracting the Stream channel block into `ensure-channels.ts` left them reading a
file that no longer contains what they pin. Both entries now name the new module;
every assertion is kept, because each one is still true there — the code moved,
the contract did not.

The trial rung's expected substring changed with it: the extracted function
early-returns on a missing appointment, so the chain reads
`appointment.trialSession?.consultantProfile` rather than optional-chaining off
the lookup variable. The negative assertion — that it is NOT the plan author's
`trialSession?.subscriptionPlan?.consultantProfile` — is untouched, which is the
part that was ever load-bearing.

Two other suites also read handlers.ts as text and were verified unaffected:
reschedule-respond pins the Novu booked-notification block and
participant-shadow-write pins the participant edge, neither of which moved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): a Razorpay error envelope with no body maps to a RefundError instead of a TypeError

`handleRazorpayRefundError` and `handleRazorpayError` both guarded with
`"error" in error`, which also passes for `{ error: undefined }` — the key is an
own property even when its value is not there. Reading `.code` off it then threw
`TypeError: Cannot read properties of undefined`, which escaped the handler that
exists precisely to classify the failure. Live E2E on 2026-09-04 lost 4 of 8
reconcile-refunds attempts to that TypeError instead of recording a classified
RefundError.

Both handlers now read the envelope through a shared helper that requires the
body to be a non-null object and its `code`/`description` to be strings, so an
empty or malformed envelope falls through to the generic error. The 409 to
REFUND_IN_FLIGHT mapping in `postRefund` is unchanged.

Part of #1353

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): the client-confirmation audit row is written inside after(), and the chat-leg queue gets an index

Review round 1 on #1391.

The `recordSystemEvent` call that records a CLIENT-side confirmation was
floated next to the response rather than scheduled with `after()`. On Netlify
the invocation freezes once the response is sent, so a detached insert races
that freeze — and the confirmations worth auditing are the slow ones, exactly
the ones whose audit row could be dropped. It now runs inside the existing
`after()` callback, awaited ahead of the pipeline, keeping its `.catch` so it
stays best-effort and can never fail a confirmation.

`Appointment` had no index able to serve the #1356 chat-leg work queue, which
filters `chatChannelEnsuredAt IS NULL AND deletedAt IS NULL` and orders by
`createdAt`; every existing index leads with a different column. Prisma cannot
express the partial index this really wants, but leading with the two equality
columns gets the same seek.

The high-`deferCount` refund runbook entry claimed such an event almost always
means the payment was never captured. A pre-`gatewayPaymentId` row whose
`payments.fetch` translation fails on credentials or availability defers
identically, so the entry now sends operators to the local capture state first
and to the gateway lookup second.

The `ensure-channels` header now states which buyers the sweep actually
re-drives: the stamp is per appointment, so for a shared `WEBINAR` or `CLASS` a
later buyer whose ensure fails after the row is stamped is caught by
`syncUserEventChannels` on their next dashboard load rather than by the sweep.

Part of #1353

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): a refund idempotency key is rejected rather than rewritten, and the channel re-drive pass has a buyer-operation budget

postRefund used to strip the characters Razorpay rejects out of the
idempotency key before sending it. That is lossy: two distinct keys can
collapse onto one header value, and Razorpay answers the second refund
with the first one's result. The key is now validated whole against the
documented rule and refused with REFUND_IDEMPOTENCY_KEY_INVALID when it
does not match. The only production caller passes Refund.id, so nothing
changes in practice and the failure mode is closed.

The chat-channel pass of the reconcile sweep took the operator's limit,
which reaches 500 appointments, and spent one outbound Stream call per
paid buyer of each — unbounded work against the ticker's function
ceiling. It now caps at 100 appointments and 500 buyer operations and
stops cleanly when the budget is spent; rows it did not reach keep a
NULL chatChannelEnsuredAt and the next run resumes oldest-first. Both
counts are reported in the run summary.

Part of #1353

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): the manual-recovery stamp is a compare-and-set, so a late capture cannot resurrect an expired payment

Both REQUIRES_MANUAL_RECOVERY branches of handlePaymentSuccess — the
capture-amount mismatch and the metadata-validation failure — stamped
SUCCEEDED with a bare `tx.payment.update({ where: { id } })`. When the
abandoned-payments sweep had already expired the row and released its
tentative hold, a late `payment.captured` flipped the EXPIRED payment back
to SUCCEEDED, leaving money recorded against an appointment that stayed
tentative with nothing left to release it.

Every status stamp in this pipeline now rides a compare-and-set: the
`paymentStatus: PENDING` predicate sits in the WHERE of an `updateMany`, as
ADR 21 requires. A count of zero means the row is already terminal, so the
handler writes nothing, records a PAYMENT system error and a Sentry warning
naming the order, the current status and the reason, and returns the same
result it returned before — the webhook is still acknowledged and Razorpay
does not retry it.

Two further stamps of the same shape take the same guard: the confirmation
write itself, which a replay now reaches, and the post-transaction stamp on
the legacy capture that loses the GiST overlap race.

The dev replay route built its metadata under the key `consulteeId` while
the schema requires `userId`, so every replayed first capture failed
validation and took the recovery branch instead of confirming the booking.
Renaming the key sends it down the confirmation path, which is why that path
needed the guard in the same change.

Closes #1439

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): post-commit notifications and the chat-channel step are bounded, so after() work cannot starve the single-connection pool

Phase 2 of handlePaymentSuccess runs inside after(), on the same warm
instance that is already serving the next inbound request, and
PG_POOL_MAX=1 means the two share one Prisma connection. Two unawaited
Novu triggers ran 39 s each while the chat-channel step waited for that
connection and died at the 3 s connect timeout.

The triggers are now collected and awaited together, each under a 5 s
deadline, before the channel step begins; the channel step has the same
deadline and, on timeout, leaves chatChannelEnsuredAt NULL so
reconcile-orphaned-confirmations re-drives it. The Novu client gets an
explicit request timeout and a bounded retry budget, so an abandoned
call cannot keep burning the instance. The ensure-channels read is a
narrow select of the ids, org ids and consultant userId the step uses
instead of a six-relation include.

Money is untouched: the Serializable transaction commits before any of
this runs.

Closes #1446

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* refactor(payments): one compare-and-set stamp helper for the capture handler, and a smaller channel pass

Extracts the identical payment->appointment->consultantProfile resolution
that the webhook success path and the checkout mock/zero/sponsored path
both ran before creating earnings into resolvePaymentForEarnings, and pulls
the channel-pass loop body in the orphaned-confirmation reconciler into
ensureChannelForOrphan so the sweep function's cognitive complexity clears
the gate. No behaviour change.

Part of #1439

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* chore(sonar): exclude test suites from copy-paste detection in Automatic Analysis too

.sonarcloud.properties is what SonarQube Cloud Automatic Analysis reads, not
sonar-project.properties (that one is staged for the future CI-based scan).
#1330 added the CPD exclusion for __tests__/**,tests/** only to the latter,
so the new-code duplication gate kept counting per-file jest.mock
scaffolding as duplication against every PR that added test coverage — this
PR's own gate failure is that boilerplate, not application code.

Part of #1439

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): a failed RazorpayX payout reaches FAILED, and a reused refund idempotency key stops pretending to be a race

Round-2 review triage on this PR plus two findings from the Razorpay
productionization audit (#1451).

The consultant payout status map had no `failed` entry, so a `payout.failed`
delivery fell through to the `|| "PENDING"` default: the payout stayed in
flight and its earnings stayed BATCHED, because the un-batch back to READY only
runs on the FAILED branch of handlePayoutWebhook. The Stripe twin ten lines
below already mapped it. One compact pin covers the consultant path.

postRefund retried every 409, but Razorpay answers 409 for two conditions and
only one of them is worth waiting on. A key replayed with a different payload
answers 409 for as long as the key lives, so the retry bought a wasted second
and reported the wrong cause; it now throws immediately as
REFUND_IDEMPOTENCY_KEY_REUSED. The key material is untouched.

reportTerminalCaptureRace re-reads the payment status instead of echoing the
caller's pre-read, which is the doctrine confirmApprovalStatus has followed
since #844 — the state named in that report is what an operator reconciles
against. Callers pass their own client so no global-client read happens inside
a transaction (#1435).

The dev mock-webhook gate loses its `VERCEL_ENV === "preview"` disjunct: it was
a runtime toggle contradicting the build-time posture the same comment claims,
and it was dead anyway because this app deploys on Netlify.

Part of #1353

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ys so when it shows an estimate, and the rate provider gets its attribution (#1414)

* fix(payments): settlement is INR at the gateway boundary, checkout says so when it shows an estimate, and the rate provider gets its attribution

A non-INR currency could reach Razorpay order creation through the org
wallet top-up: `BillingAccount.currency` accepted USD/EUR/GBP and was
forwarded verbatim alongside an amount in INR paise, so a 100000-paise
(₹1,000) top-up went out as a $1,000.00 order. `assertInrSettlement` now
runs as the first statement of `createRazorpayOrder` and
`createStripeCheckoutSession`, and the three admin-facing currency
schemas are narrowed to `z.literal("INR")`.

On the display side, all four checkout pages rendered the Total through
a live FX conversion while the gateway charged INR and the confirmation
email said INR, with nothing on the page disclosing the gap. `useCurrency`
now reports `isEstimate`, degrades `currency`/`symbol` to INR along with
`rate`, and a shared `FxEstimateNote` under each Total names the INR
amount the gateway will take. `displayCurrency` is allowlisted against a
shared, React-free code list instead of any three-letter string.

The rate provider is bounded and credited: the endpoint is configurable,
a cache older than 24 h is refused so the client degrades to honest INR,
`/api/currency` is CDN-cached and IP rate-limited, and the licence-required
attribution appears beside the navbar switcher and in the estimate note.

Also corrects the IBT claims in the gateway router (an INR order paid by
an overseas card, not a bank-transfer product), scales `refundPct` to
integer bps in event refunds, and deletes six dead FX helpers.

Closes #1396

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(checkout): a wallet- or licence-funded booking that already succeeded never opens the gateway widget

Closes #1437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): the FX estimate names the rail that will actually take the money

Review round 1 on #1414.

- /api/currency allowlists `to` against SUPPORTED_CURRENCY_CODES with zod
  instead of indexing the provider's ~160-code table, and stops echoing the
  raw query value into the 400 body.
- getExchangeRates bounds its provider fetch with AbortSignal.timeout and
  treats a timeout like a 5xx: serve the young cached copy, else throw.
- FxEstimateNote branches on the selected org's funding source. WALLET debits
  the credit pool, INVOICE defers to NET-X billing and LICENSE charges
  nothing, so none of them should have read "you will be charged ... by the
  payment gateway". The provider attribution stays on every branch.
- The navbar's rate attribution is `lg:inline`; at `xl` it was on no surface
  at all between the desktop bar and the `lg:hidden` drawer.
- The multi-currency doc's international settlement row is T+7, per Razorpay's
  own FAQ and our gateway evaluation, and the "zero-fee UPI" claim is
  corrected to zero MDR with the 2% platform fee plus GST still payable.

Part of #1396

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(checkout): the gateway SDK loads only for a rail that needs it, and the INR guard hands back the canonical code

Part of #1396

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(checkout): the Total row and its FX note are one shared component, closing the SonarCloud new-code duplication gate

The Total-row + FxEstimateNote pair rendered identically on all four
checkout pages; folding it into CheckoutTotalRow means each page adds
one call instead of a repeated multi-line block, which is what the
new-code duplication gate on PR #1414 was flagging.

Part of #1396

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* Revert "fix(checkout): the Total row and its FX note are one shared component, closing the SonarCloud new-code duplication gate"

This reverts commit c3ab9b6.

* chore(sonar): exclude test suites from copy-paste detection in Automatic Analysis too

.sonarcloud.properties is what SonarQube Cloud Automatic Analysis reads, not
sonar-project.properties (that one is staged for the future CI-based scan).
#1330 added the CPD exclusion for __tests__/**,tests/** only to the latter,
so the new-code duplication gate kept counting per-file jest.mock
scaffolding as duplication against every PR that added test coverage — this
PR's own gate failure is that boilerplate, not application code.

Part of #1439

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(checkout): round-2 review triage plus the Razorpay order-notes limits

Part of #1396

Round-2 inline comments on #1414. Fixed: the FX estimate note promised a
gateway charge on a zero total that never reaches a gateway (and the branch
chain is now early returns, which also clears the nested-ternary gate);
`loadScript` REJECTS on `script.onerror`, so the load-failure toast was
unreachable and buyers saw the generic message; `res.json()` sat outside every
stale-cache fallback in `getExchangeRates`, so a malformed 200 answered 500
with a young cache in hand, and a payload without `rates` published
`undefined`; `handleCheckout` cognitive complexity 17 against a ceiling of 15;
the navbar drawer animated through a reduced-motion request and its close
button had no accessible name.

From the Razorpay productionization audit (#1451): Razorpay caps an order's
`notes` at 15 keys and 256 characters per value, and the buyer's booking note
was forwarded verbatim with no bound anywhere — a long note made the order
impossible to create, which a buyer experiences as being unable to pay at all.
`checkoutSchema` now bounds it with a message they can act on and
`buildPaymentMetadata` truncates as a second line of defence; the full note is
still persisted on the Payment and Appointment rows. `discountCode` is dropped
from the gateway payload because the org-sponsored event case emitted exactly
15 keys with no headroom, and nothing reads it back. `BAD_REQUEST_ERROR` is
Razorpay's generic 4xx class and was reported as an authentication failure,
sending operators to rotate keys that were fine; only auth-shaped payloads keep
that wording now. `RAZORPAY_WEBHOOK_SECRET_PREVIOUS` is documented in both the
required-secrets table and `.env.sample`.

Left open as needs-decision: the webinar page showing a payable total under
LICENSE funding, because the client cannot know whether an ACTIVE
ProgramAssignment will actually absorb the booking.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…te-card scope has one open window, and invoice-number collisions page (#1434)

* fix(payouts): the stuck-payout retry moves status through a CAS, a rate-card scope has one open window, and invoice-number collisions page

The stuck-payout handler re-armed a retry with a bare update while every
sibling write in the same loop was a CAS. The cohort is read once and each
payout then costs a gateway round-trip, so a concurrent process-payouts run or
a payout webhook can move a later element; the unguarded write stamped it back
to APPROVED and the next batch paid it twice. The reset now carries
status=PROCESSING and providerPayoutId=null in its WHERE, and a zero count is
reported as skipped rather than retried or thrown.

bumpRateCard is a read-then-write that ran at the default isolation level, so
two concurrent bumps on one scope could each leave effectiveTo=null and
findEffective picked between the open windows non-deterministically. The POST
route now runs Serializable under withSerializableRetry, the new partial unique
index rate_card_one_open_window makes the invariant structural (keyed on
COALESCE expressions because three of the four scope columns are nullable), and
a surviving collision answers 409 RATE_CARD_OPEN_WINDOW_CONFLICT.

A duplicate invoice number in the subscription-invoice cron was a console.warn
in a job nobody reads. No number burns — the counter reservation shares the same
Serializable transaction — so this is alerting only: the collision now reaches
Sentry and SystemEvent with the subscription and the competing number.

The clawback dual write had no detector: reversePayoutClawback posts its
reversal best-effort inside a try/catch and the two other writers of
clawbackAmountPaise post nothing, so a payout could claim recovered cash the
journal never saw. The reconciler emits LEDGER_DUAL_WRITE_GAP for it.

Closes #1407
Closes #1405
Closes #1401
Closes #1408

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(db): the open-window index treats NULL scope columns as equal with NULLS NOT DISTINCT

An enum-to-text COALESCE expression is not IMMUTABLE, so Postgres refused the
expression index; NULLS NOT DISTINCT (Postgres 15+) gives the same guarantee
on the bare columns. Applied to the live project; the sidecar guard passes.

Part of #1405

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* docs(money): the open-window index is described as NULLS NOT DISTINCT

Part of #1405

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(finance): one Sentry capture per invoice collision, chunk the clawback lookup

Round 1 of review triage on #1434.

- The P2002 branch reported twice: reportSentryError and then
  recordSystemError, which escalates to Sentry itself and drops `context`
  on the way. The durable row now goes through recordSystemEvent so the
  collision extras survive on the one capture that carries them.
- The #1408 clawback lookup fanned every payout id into a single IN, which
  a full-scope run can push past the bind-param cap; chunked at 5,000 like
  the sibling lookups below it.
- Pinned the winning side of the #1407 retry CAS: count 1 re-arms once and
  the retryCount bump rides the guarded write.

Part of #1407

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(reconcile): the dual-write gap check compares cumulative clawback postings, not presence

`clawbackAmountPaise` is a running total, so a payout clawed back twice
whose second `Dr CASH / Cr ORG_PAYABLE` posting was swallowed still carried
a `clawback:*` transaction and the payout-id Set read it as clean. The CASH
DEBIT leg is the authoritative side — it is the money that came back — so
the summed legs are what the stamped counter is measured against now.
Total and partial gaps keep the one kind and differ in `deltaPaise`.

The finding builder is extracted pure so the pin drives it without standing
up a reconciler run; the 5,000-row chunking is unchanged.

Part of #1408

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): the RazorpayX pollers authenticate as RazorpayX, and two jobs get their HTTP twins

Round 2 triage on #1434 plus the fold-ins verified elsewhere today.

- handle-stuck-payouts: the RAZORPAY arm of mapGatewayStatus had no
  `failed` case (Stripe's has), so a bank-refused payout fell through as an
  unknown status and was skipped — PROCESSING forever with its earnings
  still linked. Delegates FAILED to handlePayoutWebhook now.
- Both payout status pollers read RAZORPAY_KEY_ID/RAZORPAY_SECRET, i.e. the
  checkout merchant, not the RazorpayX one. One exported
  resolveRazorpayXCredentials is now shared with the disbursement path, and
  the pre-flight "configured" gate tests the credentials the lookup sends.
- checkout: DomainVerificationRequiredError fell through to classifyError
  and answered 500 UNKNOWN_ERROR. Typed 403 + a toast naming the admin
  action, registered in BUSINESS_ERROR_CODES and the toast map.
- settle-invoice-accruals and tds-26q-draft-export are the last two jobs
  with no CRON_SECRET HTTP twin. Both cores are exported around their own
  cron lock; the TDS job's main() ran at import and is now behind
  require.main === module.
- Reviewer asks addressed: awaited the P2002 audit insert so it cannot lose
  its race with $disconnect, and pinned the non-positive clawback counter.

Part of #1407

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payouts): the payout-status reconciler maps a failed gateway payout to FAILED, and the clawback detector refuses unsafe integers

The RAZORPAY arm of reconcile-payout-status's mapGatewayStatus had only
`rejected`, so a payout the bank refused after it was queued fell through
as an unknown status and was skipped — which is precisely the cohort this
sweep walks. It sat PENDING/PROCESSING forever with its earnings still
batched against money that never left. FAILED delegation un-batches them
and reverses the TDS, and the failure reason carries the gateway's own
text without the pre-completion net-zero note.

The clawback dual-write detector keeps its `number` narrowing (Finding is
JSON-shaped and consumed as numbers), but a value that does not survive it
can no longer pass quietly: past 2^53 the shortfall comparison rounds and
a real gap reads as clean. Both the stamped counter and each posted CASH
leg are now checked with Number.isSafeInteger and throw naming the payout.

Part of #1407

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* chore(ci): every scheduled workflow carries a concurrency group, and the registry test keeps it that way

Closes #1413

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* chore(ci): the newer scheduled workflows get their concurrency group, and the review round-1 notes are addressed

Part of #1413

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ounded RazorpayX client, and an inbound webhook-secret rotation grace (#1451)

* fix(razorpay): productionization pass — terminal payout statuses, a bounded RazorpayX client, and an inbound webhook-secret rotation grace

Audited every Razorpay surface against current dev (post #1385/#1390/#1391
groundwork) using the razorpay skill references and the razorpay-* agent
checklists, then fixed the legit pre-MVP items that do not belong to an
in-flight PR.

- `mapPayoutStatus` treated the terminal RazorpayX status `failed` as an
  unknown string and returned PENDING, so a payout the bank refused never
  reached FAILED and its earnings stayed BATCHED.
- Every RazorpayX HTTP call used a bare `fetch` with no timeout, which can
  wedge a payout batch that holds a cron lock, and flattened Razorpay's
  `{ error: { code, description } }` envelope into one opaque message.
- Rotating `RAZORPAY_WEBHOOK_SECRET` was a hard cutover. Razorpay disables a
  webhook that has failed for 24 hours and lost events cannot be replayed, so
  `RAZORPAY_WEBHOOK_SECRET_PREVIOUS` now gives the rotation a grace window,
  mirroring ADR 09's outbound posture and reporting every delivery that
  actually lands on the old secret.

Docs gain the missing Razorpay go-live checklist and the payout-status and
idempotency-key corrections.

Part of #1377

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payouts): the X-Payout-Idempotency header stays inside RazorpayX's 36-character bound, and rotation grace needs a current secret

Review triage on #1451.

RazorpayX accepts an `X-Payout-Idempotency` value of 4-36 characters and
answers anything else with a 400, so both money-out paths were sending a
header the gateway could never accept: an organization payout derives
`payout_<uuid>` at 43 characters, and a consultant payout prefers the
`idempotencyKey` persisted on the row, `payout_<profileId>_<batchId>`, at 72.
`boundPayoutIdempotencyKey` folds any key the gateway would refuse onto a
34-character digest of itself at the point the header is written. The fold is
a pure function of the key, so the property that makes a retry safe survives
untouched — the same row always derives the same slot, and a request that
timed out after RazorpayX accepted it returns the original payout rather than
paying twice. The persisted key is deliberately left alone: it is also the
row's unique constraint and the Stripe transfer key, and neither is bounded
this way.

The RazorpayX success body was read outside the `fetch()` try, so a reply
whose headers arrived but whose body stalls tripped the same AbortSignal
there, and a non-JSON body threw a SyntaxError. Both escaped as bare
exceptions and lost the retryable code the payout callers classify on; both
are now normalised to RAZORPAYX_REQUEST_FAILED, which is the honest reading
since neither says whether the payout was accepted.

`resolveRazorpayPaymentSecrets` returned the previous secret on its own when
the current one was unset. The grace window is an aid to a rotation, not a
secret in its own right, so a deployment that has lost
`RAZORPAY_WEBHOOK_SECRET` must fail loudly on the route's 500 instead of
quietly accepting deliveries signed with a value the operator has retired.

The webhook setup table omitted `payout.failed` — the terminal event that
tells the platform a bank refused a transfer — along with two dispute events,
so an operator following it would have configured six of the seven payout
events the dispatcher handles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ebit, never by inflating the payment (#1460)

* fix(enterprise): a wallet-funded overage is collected by the wallet debit, never by inflating the payment, and cap and member-overage refusals reach the buyer as business errors

On the WALLET rail the debit taken when a booking commits is the whole
nominal price, so an over-cap booking is already paid for. The CHARGE_ORG
branch nonetheless carved from an INVOICE_ACCRUAL leg that a wallet parent
never has, fell into its own "not reachable" additive fallback, wrote an
OVERAGE_INVOICE_ACCRUAL leg and incremented Payment.amount — which broke the
leg-sum identity and made a later cancellation refund the org more than its
wallet was ever debited.

The wallet rail is now resolved first and records the OverageEvent as CHARGED
and settled against the payment whose WALLET leg collected it, with no leg and
no amount change. A surcharge, or an org-sponsored payment carrying none of the
three funding legs, fails closed with a business error instead of inflating the
amount. CHARGE_MEMBER on a WALLET account is refused at programme create and
patch time, and the checkout backstop now carries a stable code and a 409.
PROGRAM_CAP_EXHAUSTED and the per-assignment session cap reach the route with
their own status and toast, because the checkout catch rethrows any error whose
code is registered in BUSINESS_ERROR_CODES. The refund-reconcile sweep skips
STRIPE rows while the rail is fenced and counts them, instead of failing the run.

Closes #1458

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): a CHARGE_ORG overage surcharge is credited to platform revenue, and the licence rail refuses an overage it can never collect

Sentry FAMILIARISE_WEB-28 fired on the #1458 payment: the BOOKING posting is
Dr funding legs + a DISCOUNT plug clamped at >= 0 against Cr legs all derived
from Payment.originalAmount + taxAmount, so it balances only while the funding
legs sum to no more than the nominal gross. The inflated wallet payment
overshot by the marginal, threw LedgerImbalanceError and the booking committed
with no journal entry — which is what let the inflated refund through.

Removing the extra leg fixes the wallet rail by construction. The invoice rail
was unbalanced by exactly surchargePaise for the same reason: the carve keeps
basePaise inside the price, but marginal = base + surcharge raises the accrual
leg and Payment.amount by money that sits outside originalAmount. That surcharge
is a markup the platform charges the org for exceeding its own cap, not
consultant income, so the posting credits it to PLATFORM_FEE — no new ledger
account and no change to what Payment.amount means.

The licence rail cannot be balanced at all: a licence leg is deliberately zero
while Payment.amount stays at full price, and the leg-sum guard excuses that
only while the licence leg is the payment's only funding leg, so an overage leg
re-armed the comparison and assert_payment_legs_ok raised at COMMIT. It is now
refused at programme-config time and fails closed at checkout with a business
error instead of an opaque database violation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): a wallet overage surcharge is refused at configuration time, the ledger reconciler keeps invoices mandatory for accruals, and two org-sponsorship refusals answer with their own status

Review triage on #1460 plus the #1467 fold-in.

`overageBehaviorUnsupportedReason` now takes `overageSurchargeBps`, because the
surcharge rather than the behaviour is what decides collectability on the wallet
rail: the plain over-cap marginal is a slice of the price the wallet debit
already took, while a markup on top of that price is money no rail collects
afterwards. `recordWalletCollectedOrgOverage` already refuses it, but only at
checkout, after the member has picked a slot — so both the create route and the
merged-config patch route now refuse the configuration instead, which is the
guard the settlement module's own docstring claims exists.

The ledger reconciler's (G2) link check had one predicate covering ACCRUED and
CHARGED, so the payment-link exception added for wallet-collected overages also
suppressed findings for ACCRUED events. ACCRUED means "billed on an issued
invoice" and only the rollup produces it, always stamping the line item, so that
branch keeps `invoiceLineItemId` mandatory; the CHARGED branch accepts a payment
link only when the payment behind it actually carries the WALLET leg that did
the collecting.

Closes #1467: the no-active-assignment refusal and the dunning-suspend gate both
threw bare Errors, so `classifyError` fell through to UNKNOWN_ERROR and answered
500. A member whose organisation's contract had merely lapsed could not tell the
refusal from a crash, and every one of them opened a Sentry incident. Both now
carry a stable code — `PROGRAM_ASSIGNMENT_INACTIVE` (409) and
`BILLING_SUSPENDED_DUNNING` (402), the latter on the in-lock re-check of the same
gate too — registered in `BUSINESS_ERROR_CODES` with toasts that name the admin
who can unblock the booking.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…unning-suspended wallet is visible before checkout (#1457)

* fix(booking-ui): held bookings show their deadline, and a frozen or dunning-suspended wallet is visible before checkout

Closes #1428
Closes #1427
Closes #1430

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(booking-ui): the hold countdown floors instead of rounding up, and the held row stops nesting a button inside a button

The countdown sat on a payment deadline but used Math.ceil, so it promised
time the hold did not have — "2m left" with 61 seconds to go — while the
hook's own contract documented a floored value. Floor it, and word the
final minute as "under a minute left" so the honest 0 does not read as a
lapsed hold next to a live CTA.

The held timeline row also carried role="button"/tabIndex/onKeyDown around
the native "Complete payment" button, giving assistive tech two activation
targets for one action (SonarCloud flagged the same span). The row is now
inert markup and the button is the only control.

Refs #1428

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(booking-ui): a lapsed tentative hold offers a new checkout instead of a dead "Pay now"

Past Payment.expiresAt the hold is already dead for availability —
buildDeadHoldFilter (utils/slotAllocation/occupancyPolicy.ts) counts a
PENDING payment with a lapsed window as free, so another buyer can take the
slot before any sweep flips the row. Checkout also refuses to resume a stale
order (findReusablePendingOrderPayment matches only expiresAt > now) and
mints a fresh one instead. Paying the old link would therefore capture onto
a released slot and land in the #1439 terminal-race refund.

So the lapsed state now replaces the CTA rather than hiding it: one sentence
saying the window closed and the slot is released unless they book again,
plus a "Start a new checkout" action back to the consultant's profile. It
points at the picker, not a deep link to the old slot, because that time may
already be gone.

Both CTA sites — the timeline's held row and the payment card — now read one
`tentativeHoldCta` helper so they cannot drift. The hold derivation moved
above the loading/error returns since useHoldCountdown is a hook. PAY_NOW's
role-agnostic behaviour is unchanged; no server or API change.

Refs #1428

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…d balance, the disputes sweep reports its failures, and the abandoned-payments sweep fits the ticker budget (#1461)

* fix(enterprise): wallet credits and debits are null-safe on the cached balance, the disputes sweep reports its failures, and the abandoned-payments sweep fits the ticker budget

`BillingAccount.walletBalance` is nullable and every INVOICE-funded account
carries NULL, so `walletCredit`'s `{ increment }` evaluated to NULL and no-opped
while the ledger CREDIT posted — permanent cache-vs-ledger drift that only the
reconciler noticed. Both helpers now write a zero over the NULL in the same
transaction before the arithmetic and read the balance back off the mutated row
instead of coercing it with `?? 0`.

The related items from the same wave-1C run ship with it: the disputes reconcile
route no longer hardcodes a 200 over a failed run, and the sweep counts
STRIPE-gateway disputes as `skippedFenced` when the gateway fence is shut rather
than failing on a gateway we deliberately turned off; the abandoned-payments
sweep runs its gateway cancels five at a time with a per-call timeout and the
ticker sends it a limit of ten, so it fits the six-second per-target budget;
`reconcile-orphaned-confirmations` uses the shared `parseLimitParam`; and the
Razorpay webhook refuses a body over 256 KB before it reads the signature.

Closes #1459

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): an abandoned payment expires even when its gateway cancel fails, and the failure fails the run (#1464)

The abandoned-payments sweep skipped the PENDING→EXPIRED CAS for any payment
whose gateway cancel threw, while the same transaction still restored the
referral credits and released the slot or seat. The Payment then sat PENDING
for ever: its hold was gone, so nothing about it looked abandoned to a later
sweep, the credits were handed back on a live payment, and the admin pending
figure inflated with rows no job would ever heal. Because only thrown
exceptions reached errorCount, the run reported success: true.

The expiry no longer depends on the cancel. The Razorpay arm has always
expired without a real cancel, since an order cannot be cancelled, and a
capture landing after the row is EXPIRED is the terminal race #1439 owns. A
failed cancel is now recorded in errors AND counted, so the run reports
success: false, and the HTTP twin maps that to a 500 through the shared
statusFor instead of forcing 200. The CAS-miss "skipped" semantics are
untouched.

The Stripe arm also stops building a raw client around STRIPE_SECRET_KEY
outside the #1386 fence and its test-key guard. With STRIPE_ENABLED unset it
makes no gateway call and logs once per run, which is "nothing to cancel"
rather than a failure; with the fence open it goes through the fenced
getStripeClient, expires a checkout session or cancels a payment intent by id
shape, and treats resource_missing or an already-terminal intent as nothing to
cancel. The #1459 concurrency ceiling and abort timeout are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): the webhook body cap bounds the read, and an empty ?limit= is junk not absent

Review triage on #1461.

The 256 KB webhook cap was enforced only against Content-Length, which the
caller chooses: omitting it, or sending chunked, left `req.text()` free to
buffer whatever arrived before the HMAC could reject it. The raw body is now
read through a counting reader that abandons the stream the moment the cap is
passed, so the limit holds against the unauthenticated caller it was written
for. The header check stays in front of it because an honest oversized
delivery should still cost us zero bytes.

`parseLimitParam` treated `?limit=` as an absent limit, because it tested the
raw value for truthiness rather than for a missing key. That is a caller that
meant to bound a ticker sweep and sent nothing, and it got the unbounded batch
back — the silent fall-through the shared parser exists to make visible. Only
`null` is absent now; an empty value is INVALID_LIMIT like any other junk.

Two pins follow the code they cover. The wallet pin only exercised the credit
path, so it now also asserts that seeding a NULL cache to zero does not become
a licence to spend: the debit's gte guard still refuses. The webhook pin
asserted against a signature helper the route does not import, which made it
vacuous; it now mocks the route's real signature module, carries a signature
header so every later step would otherwise run, and covers the undeclared-size
stream directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): an uncancellable but still-live Stripe intent fails the sweep

Review triage on #1461, comment 5.

`payment_intent_unexpected_state` was read as "already gone" and suppressed.
It does not mean that. It means the intent's current state forbids a cancel,
which covers a `canceled` or `succeeded` intent — genuinely nothing left to do
— and equally a `processing` or `requires_capture` one, where Stripe is still
holding the buyer's money. Suppressing both left the second case invisible: the
run counted no failure and reported `success: true`, so the HTTP twin answered
2xx while a live gateway intent sat behind a payment this sweep had just marked
EXPIRED.

Stripe attaches the offending intent to the error, so the two are told apart
off the payload rather than by spending a `retrieve` round trip out of the 4 s
per-cancel budget. Only `canceled` and `succeeded` stay suppressed; any other
status, and an absent one, is now recorded and counted, because unproven is not
the same as safe. The status is read from both the top level and `raw`, since
which one carries it depends on the SDK's error wrapping.

#1464 is untouched: the PENDING to EXPIRED CAS still runs regardless of the
cancel's outcome, and #1439 still owns a capture that lands after it. What
changes is only whether an operator is told, and that state is one they should
see.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…d no longer blocks their own resume (#1462, #1463) (#1465)

* fix(payments): empty gateway notes are omitted, and a buyer's own hold no longer blocks their own resume (#1462, #1463)

Two B2C checkout defects found by the wave-1A edge-case run, both of which
end with the buyer unable to complete a purchase they already started.

#1462 — buildPaymentMetadata always emitted `startsAt`/`endsAt` (and four
other optional fields) into the gateway order notes, falling back to the
empty string. A subscription bought for a scheduling period carries no
direct slots, so those keys reached Razorpay as `""`, and
`z.string().datetime().optional()` accepts an ABSENT key but rejects an
empty string: every capture webhook for such a sale failed validation and
stamped the payment SUCCEEDED / REQUIRES_MANUAL_RECOVERY with the buyer
already charged. The builder now omits an optional field that has no
value, and validateWebhookMetadata strips empty-string entries before
normalizing and parsing, because a Razorpay order never expires and the
orders already minted with `""` keep replaying.

#1463 — validateSlotAvailability rejected any overlapping live hold,
including the requesting buyer's own PENDING hold on the very same slot
and plan, and it runs before the open-order resume (Rec C,
findReusablePendingOrderPayment) that exists to finish exactly that
order. A buyer who dismissed the gateway modal was walled out until the
hold expired. Both blocking steps, and the consultee-side conflict check
inside the checkout lock, now subtract a SELF-HOLD: same buyer, same
plan, still-PENDING and still-live payment, and exactly the requested
window. Everything else keeps blocking. Three supporting corrections come
with it: the helper's buyer parameter was being handed a ConsulteeProfile
id and compared to Payment.userId, so the duplicate-hold step could never
fire; the resume window gate read only the first 30-minute atom of a
booked run, which rejected every consultation longer than half an hour;
and superseding an open order expired its Payment while leaving the
appointment and slots on the calendar, which put the next attempt back
into the same wall. The release now runs in one transaction with the
payment CAS and goes through the guarded transitions in
lib/booking/transitions.ts.

Also downgrades the "invoice already fully credited" credit-note refusal
from an error-level Sentry issue to a modelled warning. The cumulative
cap from #1393 refusing a second reversal is the cap working, no money
moves, and the durable SystemEvent row is unchanged.

Closes #1462
Closes #1463

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payments): a hold this request could not resume keeps blocking, and consent gates read the legacy purpose codes (#1465-triage, #1472)

Review triage on #1465 found one real hole in the #1463 self-hold exclusion.
`findSelfHoldAppointmentIds` excluded a buyer's own live hold on buyer, plan,
status, deletion and window alone, but `findReusablePendingOrderPayment` will
only resume or supersede a candidate whose `paymentGateway` and
`organizationId` also match this request. A hold minted on another gateway, or
under another org scope, was therefore taken off the calendar by a request that
could neither adopt nor expire it: the same buyer minted a SECOND tentative
appointment and a second payable gateway order over the same window, and both
orders could capture. The exclusion now carries the resume gate's own two
terms, so an unresumable hold keeps blocking and the buyer waits out its
`expiresAt` instead of double-paying. The server-resolved org scope is threaded
from `handleCheckout` through `calculateAmountAndValidate`,
`revalidateInsideLock` and `createConsultationBooking`, defaulting to null
(personal) so a caller that cannot resolve it fails closed. Step 2's
duplicate-attempt guard deliberately keeps the unscoped liveness filter: it
asks whether the buyer holds this window at all, and scoping it would let a
second attempt on another gateway slip past the guard entirely. The plan-scope
ternary chain became a switch, which is also the sonar S3358 finding on this
PR's new code.

Folding in #1472: `checkConsent`, `checkConsentBatch` and `withdrawConsent`
matched `purposeCodes` against the canonical code exactly, so an artifact
written under the pre-taxonomy kebab-case code (`session-booking`) was invisible
to the fail-closed booking gate and every booking against that consultant
answered 403 although `withdrawnAt` was null. A consent record is a legal
artifact, so the gate has to recognise every code the platform ever wrote:
`purposeCodeAliases` resolves a canonical code to itself plus each legacy alias
that normalises to it, and the three lookups query `hasSome` over that set.
Writes still normalise to the canonical form and the DB is not backfilled
(pre-MVP reset). `checkConsent` keeps its `db` parameter — under PG_POOL_MAX=1
it must read through the caller's transaction (#1435).

The other two review comments are answered without a code change. Coupling the
credit-note refusal event to the refund transaction is refused because
`recordSystemEvent` is a deliberately non-cascading global-client sink and the
operator signal already survives on the Sentry warning. Cancelling a superseded
gateway order is refused because `cancelRazorpayOrder` cannot make a Razorpay
order unpayable — it only fetches the order's payments and logs — so the call
would add in-lock gateway round-trips and remove nothing; a late capture is
already the modelled CAPTURE_AFTER_TERMINAL_PAYMENT path.

Closes #1472

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…t the reconciler loads (#1469)

`@react-pdf/renderer` is in Next's built-in server-externals list, so the
deployed function loads it — and `@react-pdf/reconciler` with it — through
Node. That reconciler picks one of three bundled reconcilers by reading
`React.version`, lands on this project's userland React 18.3.1, and
reconciler-23 accepts only elements stamped `Symbol.for("react.element")`.
Route-handler code is compiled in the `rsc` layer against Next's vendored
React 19.2, which stamps `Symbol.for("react.transitional.element")`. Every
element therefore reached the reconciler as an unrecognised object and all
four statutory PDF routes answered 500 with React error #31.

A `@jsxImportSource` pragma routes element creation in lib/pdf back through
the runtime Node resolves, so both sides of the external boundary share one
React. The tracer cannot see that require, so `react` is named explicitly for
the four PDF routes.

Closes #1468


Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ng, and org earnings are released from hold (#1473)

* fix(payouts): the org payout journal clears the payable pre-withholding, and org earnings are released from hold (#1470, #1471)

The ORG_PAYOUT posting read `netPayoutPaise` as if it were post-TDS. It is the
pre-withholding host-org share; `amountPaise` is what the rail actually
transfers. The old legs (Dr ORG_PAYABLE net+tds, Cr CASH net) balanced, so the
write-time check and the nightly imbalance finding both accepted them while
clearing the payable and crediting cash by exactly the withholding too much on
every host-org payout, and `markOrgPayoutReversed` mirrored the same wrong shape
so only a payout that stayed COMPLETED carried the overstatement. Completion and
reversal now post the corrected legs under one shared assertion that
`amountPaise + tdsAmountPaise === netPayoutPaise`, and they refuse to journal a
guess when it fails: a SystemEvent plus a Sentry report raised from outside the
transaction (a global-client write while a $transaction holds the only pooled
connection deadlocks under PG_POOL_MAX=1), then a throw so the CAS rolls back
for the at-least-once webhook and the stuck-payout sweep to re-drive. The #1354
TDS return input stops adding `tdsAmountPaise` on top of a figure that already
includes it. The two schema columns now say which is pre- and post-withholding.

Every scheduled release-earnings entry point imports the script, and the script
touched only `consultantEarnings`, so `OrganizationEarnings` rows never left
PENDING and a hosting organisation's retained share could never reach a payout
batch (`createOrgPayoutBatch` selects READY only). The script now releases both
tables, each in its own Serializable transaction with `status: PENDING` restated
on the claim and its own copy of the ticker limit, oldest hold first, and the
two counts are reported separately through the cleanup twin, the GitHub Actions
outputs and the admin system-jobs runner. The dead dual implementation in
earnings-service, which nothing called, is deleted with its barrel export.

The reconciler's ORG_PAYOUT_TOTAL_MISMATCH check is scoped to the statuses where
the earnings attachment is expected to hold. FAILED, REVERSED and CANCELLED
payouts release their earnings back to READY with `orgPayoutId` cleared by
design, so every one of them was being reported as drift.

Closes #1470
Closes #1471

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(payouts): the org withholding assertion refuses negative figures, not just a broken identity (#1470)

The equation `amountPaise + tdsAmountPaise === netPayoutPaise` is satisfied by
a negative row as readily as by a correct one, and the posting site journals
nothing unless `netPayoutPaise > 0` — so a payout carrying negative figures
would have settled COMPLETED with its earnings marked PAID and no ORG_PAYOUT
entry at all. `createOrgPayoutBatch` rejects a non-positive batch and nothing
else mints an OrganizationPayout, so this closes a hand-edited-row hole rather
than a live path, but it is the same class of unpostable money the identity
guard exists for and it costs one comparison. Zero stays legal because a zero
payout has nothing to post and is skipped deliberately.

Also documents the refusal in the ledger-postings reference and gives the
ORG_PAYOUT block the blank line markdownlint asked for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…pt, not a 500 (#1479)

* fix(payments): an overdrawn org wallet answers 402 with a top-up prompt, not a 500

`WalletInsufficientFundsError` carried no machine-readable code, so checkout's
catch did not recognise it, rewrote it to "Failed to record payment
information. Please try again.", and reported it to Sentry as an unexpected
fault. An organisation that had simply spent its wallet down could not tell the
refusal from a crash, retried a booking that can never succeed, and paged us
every time.

The error now carries `code = "WALLET_INSUFFICIENT_FUNDS"` and `httpStatus =
402`, which is registered in `BUSINESS_ERROR_CODES` alongside the #1467 codes.
That is enough for the existing `isBusinessErrorCode` rethrow to let it through
the checkout transaction's catch intact, and the toast map now names the one
action that clears it. Checkout tags it `expected: true` at both report sites:
the inner one had a hand-maintained code list, and the outer one re-reported
every code-carrying refusal as a fault immediately after the inner one had
excused it. The route gets its own branch so the buyer sees actionable copy
rather than a message naming the billing account and a paise figure, and so the
refusal skips the unconditional `captureException` below it.

Only the requested amount is reported: the overdraft guard is a conditional
`updateMany` that refuses without reading the row, and re-reading the balance
would add a query inside the checkout transaction that PG_POOL_MAX=1 serialises.

Closes #1477

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(checkout): the route reports a business-coded refusal as an outcome, not an exception

The generic tail of `POST /api/checkout` called `Sentry.captureException`
before it had classified anything, so the only refusals that escaped an
error-level event were the ones with an explicit `instanceof` branch above that
line. Every code-carrying refusal that relies on the classifier instead —
PROGRAM_CAP_EXHAUSTED and PROGRAM_SESSION_CAP_REACHED (#1458), the OVERAGE_*
codes, PROGRAM_ASSIGNMENT_INACTIVE and BILLING_SUSPENDED_DUNNING (#1467) —
answered the buyer with the right status and toast and still opened an incident
each time an organisation's contract had merely lapsed.

The tail now asks `isBusinessErrorCode` the same question the classifier is
about to answer: a coded refusal goes through `reportSentryError` with
`expected: true`, which lands at info, and anything else keeps the capture it
had. The explicit branches above are untouched.

Refs #1477

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(checkout): the wallet-overdraft branch reports its refusal like every other business outcome

Review triage on #1479: the explicit WalletInsufficientFundsError branch
returned before the route's expected-outcome report, so it was the one
business-coded refusal with no Sentry breadcrumb. It now reports at info
with expected: true, matching the generic path below it.

Part of #1477

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@teetangh

teetangh commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai ignore

@netlify

netlify Bot commented Sep 5, 2026

Copy link
Copy Markdown

Deploy Preview for familiarise ready!

Name Link
🔨 Latest commit 5434993
🔍 Latest deploy log https://app.netlify.com/projects/familiarise/deploys/6a9c021ce1e24c0007c44835
😎 Deploy Preview https://deploy-preview-1491--familiarise.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 36 (🔴 down 17 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (no change from production)
SEO: 90 (no change from production)
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 2bbefce8-c109-4f8e-bced-56149e973f1c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Note

Reviews paused

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews paused.

@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

@teetangh
teetangh merged commit 576b97c into prod Sep 5, 2026
9 checks passed
@teetangh
teetangh deleted the release/dev-to-prod-2026-09-05 branch September 5, 2026 12:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant